--- title: "05-当 requests 不够用时——进入动态网页与浏览器自动化" created: 2026-05-06 tags: - 项目 aliases: - 当 requests 不够用时 —— 进入动态网页与浏览器自动化 --- # 当 requests 不够用时——进入动态网页与浏览器自动化 前面我们的爬虫跑得很顺,因为目标站点是"服务端渲染"——HTML 直接把数据吐给你。 但**真实的招聘网站几乎没有这么友好**。 猎聘、BOSS 直聘、前程无忧、鱼泡——用 `requests.get()` 打开它们,拿回来的 HTML 里**几乎是空的**,只有一堆 `
` 和一坨 JS 代码。 这一讲就要回答一个核心问题:**为什么** `requests` **抓不到数据?当它失效时,我们该怎么办?** 学完这一讲,会真正理解:**项目里为什么要上 Playwright、为什么要监听** `page.on("response")`**、为什么要加代理和反自动化伪装**——这些不是炫技,而是**被网站一步步逼出来的**。 ## **一、先看一个失败案例:用** `requests` **抓动态网站** 我们换一个目标站点:。这是 ToScrape 专门做的"JS 渲染版",和上一讲那个静态版长得一模一样,但**数据是 JS 动态生成的**。 ![[2-Learning/05-项目/03-Python与爬虫/07-爬虫课程六讲/assets/image-a66615d9.png]] ```python import requests from bs4 import BeautifulSoup resp = requests.get("https://quotes.toscrape.com/js/", headers={"User-Agent": "Mozilla/5.0"}) soup = BeautifulSoup(resp.text, "lxml") quotes = soup.select("div.quote") print(f"找到 {len(quotes)} 条数据") print("--- HTML 原文 ---") print(resp.text) ``` 运行结果大吃一惊: 找到 0 条数据 ![[2-Learning/05-项目/03-Python与爬虫/07-爬虫课程六讲/assets/image-dd70a90d.png]] 但用浏览器打开同一个网址,能清清楚楚看到 10 条名言。差别在哪? ## **二、关键原理:浏览器和** `requests` **看到的不是同一个东西** 这是初学者最容易卡住的地方,必须把它讲透。 ****`requests` **拿到的:** 服务器**最初**返回的 HTML —— 就像菜谱 **浏览器最终显示的:** JS 执行完毕后的 HTML —— 就像做好的菜 ![[2-Learning/05-项目/03-Python与爬虫/07-爬虫课程六讲/assets/image-18e9cf5a.png]] **所以** `requests` **抓不到数据,不是它"没本事",而是它根本不执行 JS**。它只是"下载 HTML 的工具",**不是浏览器**。 那要怎么办?有两条路。 ## **三、思路 A:不用浏览器,自己找接口** 既然 JS 会去请求 API 拿数据,那我们**直接找到那个 API,自己请求它**,不就拿到 JSON 了吗? 打开 `F12` → Network → 刷新页面 → 找到那个返回数据的请求。在某些网站上,这招能用,而且效率最高。**项目里"API 直连通道"(**`auto_api/`**)就是这么干的**——百度、京东这些大厂的招聘 API 接口稳定、参数简单、能直接 `requests.get` 拿 JSON。 但这条路在大多数招聘网站上**走不通**,因为: 1. **接口参数有签名/加密**:猎聘的搜索接口带着一堆 token,自己构造极难 2. **接口需要登录态**:没登录直接 401 3. **接口随时改**:今天调通了,明天网站升级你又得重做 4. **接口返回的数据被前端二次加工**:你拿到的 JSON 不是页面显示的最终版 所以,对绝大多数招聘网站,我们走第二条路。 ## **四、思路 B:启动一个真浏览器 —— Playwright 登场** 既然 `requests` 不执行 JS,那我**直接开一个真浏览器,让它替我加载完页面,我再读最终的 HTML** 不就行了? 这就是 **Playwright** 干的事。它本质上是"用代码遥控一个 Chrome"。 ### 4.1 安装 ```bash pip install playwright playwright install chromium # 下载浏览器内核(只用做一次) ``` ### 4.2 用 Playwright 抓那个失败的 JS 页面 ```python from playwright.sync_api import sync_playwright from bs4 import BeautifulSoup with sync_playwright() as p: browser = p.chromium.launch(headless=False) # False 让你能看到浏览器窗口 page = browser.new_page() page.goto("https://quotes.toscrape.com/js/") page.wait_for_load_state("networkidle") # 等所有网络请求完成 html = page.content() # 拿到 JS 执行后的最终 HTML soup = BeautifulSoup(html, "lxml") quotes = soup.select("div.quote") print(f"Playwright 抓到 {len(quotes)} 条") browser.close() ``` ![[2-Learning/05-项目/03-Python与爬虫/07-爬虫课程六讲/assets/image-eebcb44e.png]] 成功!会看到一个真的 Chrome 窗口弹出,自动打开网页,等几秒,然后关闭。 **为什么这次成功了?** 因为 Playwright **是个真浏览器**,它会执行 JS、发起 API 请求、把数据渲染到 DOM 里。我们读的是"成品菜",不是"菜谱"。 ### **4.3 Playwright 的核心 API** ```text page.goto(url) # 打开网页 page.wait_for_load_state("networkidle") # 等到网络空闲(数据加载完了) page.wait_for_selector("div.quote") # 等某个元素出现(更精确) page.click("button.next") # 点击 page.fill("input.search", "Python") # 输入 page.select_option("select.city", "北京") # 下拉选择 page.mouse.wheel(0, 3000) # 滚动 3000 像素 page.content() # 拿到当前 HTML page.query_selector("h1").inner_text() # 直接读元素文本 ``` ## **五、用 Playwright 升级我们的职位爬虫** 把前面那个静态爬虫,改成 Playwright 版,顺便加上"翻页"和"等待数据加载": ```python """ 生产级别的 Playwright 爬虫模板 功能:支持动态渲染页面、翻页、等待数据加载、去重、断点续跑、异常处理、节奏控制 """ import os import json import time import random from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout from bs4 import BeautifulSoup # ==================== 配置区 ==================== # 数据保存目录 DATA_DIR = "data/jobs" # 进度记录文件路径 PROGRESS_FILE = "data/progress.txt" # 列表页URL(如果有分页,可以改成带页码的格式) LIST_URL = "https://realpython.github.io/fake-jobs/" # 浏览器配置 HEADLESS = False # True为无头模式(不显示浏览器窗口) TIMEOUT = 30000 # 超时时间(毫秒) # ==================== 解析函数 ==================== def parse_list_page(html): """ 解析列表页,提取职位基本信息 Args: html (str): 列表页HTML内容 Returns: list: 职位信息字典列表 """ soup = BeautifulSoup(html, "lxml") jobs = [] for card in soup.select("div.card-content"): # 查找"Apply"按钮获取正确的详情页链接 apply_link = card.find("a", string="Apply") link = apply_link["href"] if apply_link else None jobs.append({ "title": card.select_one("h2.title").get_text(strip=True), "company": card.select_one("h3.company").get_text(strip=True), "location": card.select_one("p.location").get_text(strip=True), "link": link, }) return jobs def parse_detail_page(html): """ 解析详情页,提取职位描述 Args: html (str): 详情页HTML内容 Returns: str: 职位描述文本 """ soup = BeautifulSoup(html, "lxml") node = soup.select_one("div.content") return node.get_text(strip=True) if node else "" # ==================== 数据存储函数 ==================== def save(job): """ 将职位数据保存为JSON文件 Args: job (dict): 职位信息字典 Returns: str: 保存的文件路径 """ job_id = job["link"].rstrip("/").split("/")[-1].replace(".html", "") path = f"{DATA_DIR}/{job_id}.json" with open(path, "w", encoding="utf-8") as f: json.dump(job, f, ensure_ascii=False, indent=2) return path def already_spider(job): """ 检查职位是否已经爬取过 Args: job (dict): 职位信息字典 Returns: bool: True表示已爬取 """ if not job["link"]: return False job_id = job["link"].rstrip("/").split("/")[-1].replace(".html", "") return os.path.exists(f"{DATA_DIR}/{job_id}.json") # ==================== 进度管理函数 ==================== def load_progress(): """ 加载爬取进度 Returns: int: 已处理的职位索引 """ if os.path.exists(PROGRESS_FILE): try: return int(open(PROGRESS_FILE).read()) except: return 0 return 0 def save_progress(idx): """ 保存爬取进度 Args: idx (int): 当前处理到的索引 """ with open(PROGRESS_FILE, "w") as f: f.write(str(idx)) # ==================== 主程序入口 ==================== if __name__ == "__main__": # 创建数据保存目录 os.makedirs(DATA_DIR, exist_ok=True) all_jobs = [] # 存储所有职位 with sync_playwright() as p: # 启动浏览器 browser = p.chromium.launch(headless=HEADLESS) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", viewport={"width": 1280, "height": 800}, ) page = context.new_page() try: # ---- 第1步:抓取列表页(支持翻页)---- current_page = 1 max_pages = 3 # 最大翻页数,根据需要调整 while current_page <= max_pages: print(f"\n===== 正在抓取第 {current_page} 页 =====") # 如果是第一页,直接访问;否则点击下一页按钮 if current_page == 1: page.goto(LIST_URL, timeout=TIMEOUT, wait_until="domcontentloaded") else: # 尝试点击下一页按钮(根据实际网站结构调整选择器) next_btn = page.query_selector("a.next-page, button.next, .pagination a:last-child") if not next_btn: print("没有更多页面了") break next_btn.click() # 等待新页面加载完成 page.wait_for_load_state("networkidle", timeout=TIMEOUT) # 等待职位卡片加载完成 try: page.wait_for_selector("div.card-content", timeout=TIMEOUT) except PlaywrightTimeout: print(f"第 {current_page} 页加载超时") break # 额外等待,确保动态内容完全加载 page.wait_for_timeout(1000) # 解析当前页的职位列表 jobs = parse_list_page(page.content()) print(f"第 {current_page} 页抓到 {len(jobs)} 条") if not jobs: print("没有更多数据了") break all_jobs.extend(jobs) current_page += 1 # 翻页间隔 time.sleep(random.uniform(1, 2)) print(f"\n列表页总共抓到 {len(all_jobs)} 条职位") # ---- 第2步:加载进度,实现断点续跑 ---- start = load_progress() print(f"从第 {start} 条开始抓取详情") # ---- 第3步:逐个抓取详情页 ---- for idx, job in enumerate(all_jobs): # 跳过已处理的职位(断点续跑) if idx < start: continue # 跳过无效链接或已爬取的职位 if not job["link"]: print(f"[{idx + 1}/{len(all_jobs)}] 跳过: 无链接 - {job['title']}") save_progress(idx + 1) continue if already_spider(job): print(f"[{idx + 1}/{len(all_jobs)}] 跳过已抓: {job['title']}") save_progress(idx + 1) continue try: print(f"[{idx + 1}/{len(all_jobs)}] 正在抓取: {job['title']}") # 打开详情页 page.goto(job["link"], timeout=TIMEOUT, wait_until="domcontentloaded") # 等待详情内容加载 page.wait_for_selector("div.content", timeout=TIMEOUT) # 额外等待,确保动态内容完全加载 page.wait_for_timeout(500) # 解析详情内容 job["description"] = parse_detail_page(page.content()) # 保存数据 saved_to = save(job) print(f" 已保存: {saved_to}") except Exception as e: print(f" 抓取失败: {e}") # 更新进度 save_progress(idx + 1) # 随机休眠,控制请求频率 time.sleep(random.uniform(1, 2)) print("\n全部完成!") except Exception as e: print(f"发生错误: {e}") finally: # 关闭浏览器 browser.close() ``` **和前面的** `requests` **版相比,变化只有两处:** 1. 用 `page.goto() + page.content()` 替代 `requests.get().text` 2. 加了 `wait_for_selector` 等数据加载 **其他逻辑(解析、落盘、去重、节奏控制)和第 4 讲一模一样**——这就是所谓的"积木思想":换了发请求的方式,其他积木都能继续用。 ## **六、性能优化:屏蔽图片和字体** Playwright 默认会下载所有资源——图片、字体、视频、广告——这些**对抓数据毫无用处,只会拖慢速度、烧代理流量**。我们直接屏蔽掉: ```python def block_resources(route): if route.request.resource_type in ("image", "font", "media"): route.abort() # 取消这个请求 else: route.continue_() # 其他正常放行 context.route("**/*", block_resources) ``` ```python """ 生产级别的 Playwright 爬虫模板 功能:支持动态渲染页面、翻页、等待数据加载、去重、断点续跑、异常处理、节奏控制、资源屏蔽优化 """ import os import json import time import random from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout from bs4 import BeautifulSoup # ==================== 配置区 ==================== # 数据保存目录 DATA_DIR = "data/jobs" # 进度记录文件路径 PROGRESS_FILE = "data/progress.txt" # 列表页URL(如果有分页,可以改成带页码的格式) LIST_URL = "https://realpython.github.io/fake-jobs/" # 浏览器配置 HEADLESS = False # True为无头模式(不显示浏览器窗口) TIMEOUT = 30000 # 超时时间(毫秒) # ==================== 解析函数 ==================== def parse_list_page(html): """ 解析列表页,提取职位基本信息 Args: html (str): 列表页HTML内容 Returns: list: 职位信息字典列表 """ soup = BeautifulSoup(html, "lxml") jobs = [] for card in soup.select("div.card-content"): # 查找"Apply"按钮获取正确的详情页链接 apply_link = card.find("a", string="Apply") link = apply_link["href"] if apply_link else None jobs.append({ "title": card.select_one("h2.title").get_text(strip=True), "company": card.select_one("h3.company").get_text(strip=True), "location": card.select_one("p.location").get_text(strip=True), "link": link, }) return jobs def parse_detail_page(html): """ 解析详情页,提取职位描述 Args: html (str): 详情页HTML内容 Returns: str: 职位描述文本 """ soup = BeautifulSoup(html, "lxml") node = soup.select_one("div.content") return node.get_text(strip=True) if node else "" # ==================== 数据存储函数 ==================== def save(job): """ 将职位数据保存为JSON文件 Args: job (dict): 职位信息字典 Returns: str: 保存的文件路径 """ job_id = job["link"].rstrip("/").split("/")[-1].replace(".html", "") path = f"{DATA_DIR}/{job_id}.json" with open(path, "w", encoding="utf-8") as f: json.dump(job, f, ensure_ascii=False, indent=2) return path def already_spider(job): """ 检查职位是否已经爬取过 Args: job (dict): 职位信息字典 Returns: bool: True表示已爬取 """ if not job["link"]: return False job_id = job["link"].rstrip("/").split("/")[-1].replace(".html", "") return os.path.exists(f"{DATA_DIR}/{job_id}.json") # ==================== 进度管理函数 ==================== def load_progress(): """ 加载爬取进度 Returns: int: 已处理的职位索引 """ if os.path.exists(PROGRESS_FILE): try: return int(open(PROGRESS_FILE).read()) except: return 0 return 0 def save_progress(idx): """ 保存爬取进度 Args: idx (int): 当前处理到的索引 """ with open(PROGRESS_FILE, "w") as f: f.write(str(idx)) # ==================== 主程序入口 ==================== if __name__ == "__main__": # 创建数据保存目录 os.makedirs(DATA_DIR, exist_ok=True) all_jobs = [] # 存储所有职位 with sync_playwright() as p: # 启动浏览器 browser = p.chromium.launch(headless=HEADLESS) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", viewport={"width": 1280, "height": 800}, ) # 性能优化:屏蔽图片、字体、视频等无关资源 def block_resources(route): """ 拦截并屏蔽不必要的资源请求,提升爬取速度 屏蔽的资源类型: - image: 图片(jpg, png, gif, webp等) - font: 字体文件(woff, ttf等) - media: 音视频文件 - stylesheet: CSS样式表(可选,如果不需要渲染样式) """ if route.request.resource_type in ("image", "font", "media"): route.abort() # 取消这个请求,节省带宽和时间 else: route.continue_() # 其他资源正常放行(HTML、JS、XHR等) # 应用路由拦截规则,**/* 匹配所有请求 context.route("**/*", block_resources) page = context.new_page() try: # ---- 第1步:抓取列表页(支持翻页)---- current_page = 1 max_pages = 3 # 最大翻页数,根据需要调整 while current_page <= max_pages: print(f"\n===== 正在抓取第 {current_page} 页 =====") # 如果是第一页,直接访问;否则点击下一页按钮 if current_page == 1: page.goto(LIST_URL, timeout=TIMEOUT, wait_until="domcontentloaded") else: # 尝试点击下一页按钮(根据实际网站结构调整选择器) next_btn = page.query_selector("a.next-page, button.next, .pagination a:last-child") if not next_btn: print("没有更多页面了") break next_btn.click() # 等待新页面加载完成 page.wait_for_load_state("networkidle", timeout=TIMEOUT) # 等待职位卡片加载完成 try: page.wait_for_selector("div.card-content", timeout=TIMEOUT) except PlaywrightTimeout: print(f"第 {current_page} 页加载超时") break # 额外等待,确保动态内容完全加载 page.wait_for_timeout(1000) # 解析当前页的职位列表 jobs = parse_list_page(page.content()) print(f"第 {current_page} 页抓到 {len(jobs)} 条") if not jobs: print("没有更多数据了") break all_jobs.extend(jobs) current_page += 1 # 翻页间隔 time.sleep(random.uniform(1, 2)) print(f"\n列表页总共抓到 {len(all_jobs)} 条职位") # ---- 第2步:加载进度,实现断点续跑 ---- start = load_progress() print(f"从第 {start} 条开始抓取详情") # ---- 第3步:逐个抓取详情页 ---- for idx, job in enumerate(all_jobs): # 跳过已处理的职位(断点续跑) if idx < start: continue # 跳过无效链接或已爬取的职位 if not job["link"]: print(f"[{idx + 1}/{len(all_jobs)}] 跳过: 无链接 - {job['title']}") save_progress(idx + 1) continue if already_spider(job): print(f"[{idx + 1}/{len(all_jobs)}] 跳过已抓: {job['title']}") save_progress(idx + 1) continue try: print(f"[{idx + 1}/{len(all_jobs)}] 正在抓取: {job['title']}") # 打开详情页 page.goto(job["link"], timeout=TIMEOUT, wait_until="domcontentloaded") # 等待详情内容加载 page.wait_for_selector("div.content", timeout=TIMEOUT) # 额外等待,确保动态内容完全加载 page.wait_for_timeout(500) # 解析详情内容 job["description"] = parse_detail_page(page.content()) # 保存数据 saved_to = save(job) print(f" 已保存: {saved_to}") except Exception as e: print(f" 抓取失败: {e}") # 更新进度 save_progress(idx + 1) # 随机休眠,控制请求频率 time.sleep(random.uniform(1, 2)) print("\n全部完成!") except Exception as e: print(f"发生错误: {e}") finally: # 关闭浏览器 browser.close() ``` 加上这一招,页面打开速度通常能**提升 2~5 倍**。猎聘和鱼泡爬虫都用了这招。 ## **七、反自动化伪装:让网站认不出你是机器人** 网站怎么识别"你是不是真人"?最简单的一个判断点: ```text navigator.webdriver // 真人浏览器 = undefined,Playwright = true ``` 很多反爬系统会检查这个字段。我们把它伪装掉: ```java context.add_init_script(""" Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); """) ``` **这是猎聘、鱼泡项目里所有爬虫的标配第一行代码**。能挡住 80% 的初级反爬。 更高级的伪装(伪造 plugins、languages、WebGL 指纹)有专门的库 `playwright-stealth`,但对中等强度的反爬,上面这一行往往就够了。 ```javascript """ 生产级别的 Playwright 爬虫模板 功能:支持动态渲染页面、翻页、等待数据加载、去重、断点续跑、异常处理、节奏控制、资源屏蔽优化、反自动化伪装 """ import os import json import time import random from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout from bs4 import BeautifulSoup # ==================== 配置区 ==================== # 数据保存目录 DATA_DIR = "data/jobs" # 进度记录文件路径 PROGRESS_FILE = "data/progress.txt" # 列表页URL(如果有分页,可以改成带页码的格式) LIST_URL = "https://realpython.github.io/fake-jobs/" # 浏览器配置 HEADLESS = False # True为无头模式(不显示浏览器窗口) TIMEOUT = 30000 # 超时时间(毫秒) # ==================== 解析函数 ==================== def parse_list_page(html): """ 解析列表页,提取职位基本信息 Args: html (str): 列表页HTML内容 Returns: list: 职位信息字典列表 """ soup = BeautifulSoup(html, "lxml") jobs = [] for card in soup.select("div.card-content"): # 查找"Apply"按钮获取正确的详情页链接 apply_link = card.find("a", string="Apply") link = apply_link["href"] if apply_link else None jobs.append({ "title": card.select_one("h2.title").get_text(strip=True), "company": card.select_one("h3.company").get_text(strip=True), "location": card.select_one("p.location").get_text(strip=True), "link": link, }) return jobs def parse_detail_page(html): """ 解析详情页,提取职位描述 Args: html (str): 详情页HTML内容 Returns: str: 职位描述文本 """ soup = BeautifulSoup(html, "lxml") node = soup.select_one("div.content") return node.get_text(strip=True) if node else "" # ==================== 数据存储函数 ==================== def save(job): """ 将职位数据保存为JSON文件 Args: job (dict): 职位信息字典 Returns: str: 保存的文件路径 """ job_id = job["link"].rstrip("/").split("/")[-1].replace(".html", "") path = f"{DATA_DIR}/{job_id}.json" with open(path, "w", encoding="utf-8") as f: json.dump(job, f, ensure_ascii=False, indent=2) return path def already_spider(job): """ 检查职位是否已经爬取过 Args: job (dict): 职位信息字典 Returns: bool: True表示已爬取 """ if not job["link"]: return False job_id = job["link"].rstrip("/").split("/")[-1].replace(".html", "") return os.path.exists(f"{DATA_DIR}/{job_id}.json") # ==================== 进度管理函数 ==================== def load_progress(): """ 加载爬取进度 Returns: int: 已处理的职位索引 """ if os.path.exists(PROGRESS_FILE): try: return int(open(PROGRESS_FILE).read()) except: return 0 return 0 def save_progress(idx): """ 保存爬取进度 Args: idx (int): 当前处理到的索引 """ with open(PROGRESS_FILE, "w") as f: f.write(str(idx)) # ==================== 主程序入口 ==================== if __name__ == "__main__": # 创建数据保存目录 os.makedirs(DATA_DIR, exist_ok=True) all_jobs = [] # 存储所有职位 with sync_playwright() as p: # 启动浏览器 browser = p.chromium.launch(headless=HEADLESS) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", viewport={"width": 1280, "height": 800}, ) # 反自动化伪装:注入JavaScript脚本,隐藏Playwright特征 context.add_init_script(""" // 伪装 navigator.webdriver 字段 // 真人浏览器返回 undefined,Playwright默认返回 true Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); // 伪装 Chrome 对象(某些网站会检查 window.chrome) window.chrome = { runtime: {}, loadTimes: function() {}, csi: function() {}, app: {} }; // 伪装 plugins(插件列表) Object.defineProperty(navigator, 'plugins', { get: () => [ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' }, { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' }, { name: 'Native Client', filename: 'internal-nacl-plugin' } ] }); // 伪装 languages(语言设置) Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'] }); // 移除 automation 相关的特征 delete navigator.__proto__.webdriver; """) # 性能优化:屏蔽图片、字体、视频等无关资源 def block_resources(route): """ 拦截并屏蔽不必要的资源请求,提升爬取速度 屏蔽的资源类型: - image: 图片(jpg, png, gif, webp等) - font: 字体文件(woff, ttf等) - media: 音视频文件 - stylesheet: CSS样式表(可选,如果不需要渲染样式) """ if route.request.resource_type in ("image", "font", "media"): route.abort() # 取消这个请求,节省带宽和时间 else: route.continue_() # 其他资源正常放行(HTML、JS、XHR等) # 应用路由拦截规则,**/* 匹配所有请求 context.route("**/*", block_resources) page = context.new_page() try: # ---- 第1步:抓取列表页(支持翻页)---- current_page = 1 max_pages = 3 # 最大翻页数,根据需要调整 while current_page <= max_pages: print(f"\n===== 正在抓取第 {current_page} 页 =====") # 如果是第一页,直接访问;否则点击下一页按钮 if current_page == 1: page.goto(LIST_URL, timeout=TIMEOUT, wait_until="domcontentloaded") else: # 尝试点击下一页按钮(根据实际网站结构调整选择器) next_btn = page.query_selector("a.next-page, button.next, .pagination a:last-child") if not next_btn: print("没有更多页面了") break next_btn.click() # 等待新页面加载完成 page.wait_for_load_state("networkidle", timeout=TIMEOUT) # 等待职位卡片加载完成 try: page.wait_for_selector("div.card-content", timeout=TIMEOUT) except PlaywrightTimeout: print(f"第 {current_page} 页加载超时") break # 额外等待,确保动态内容完全加载 page.wait_for_timeout(1000) # 解析当前页的职位列表 jobs = parse_list_page(page.content()) print(f"第 {current_page} 页抓到 {len(jobs)} 条") if not jobs: print("没有更多数据了") break all_jobs.extend(jobs) current_page += 1 # 翻页间隔 time.sleep(random.uniform(1, 2)) print(f"\n列表页总共抓到 {len(all_jobs)} 条职位") # ---- 第2步:加载进度,实现断点续跑 ---- start = load_progress() print(f"从第 {start} 条开始抓取详情") # ---- 第3步:逐个抓取详情页 ---- for idx, job in enumerate(all_jobs): # 跳过已处理的职位(断点续跑) if idx < start: continue # 跳过无效链接或已爬取的职位 if not job["link"]: print(f"[{idx + 1}/{len(all_jobs)}] 跳过: 无链接 - {job['title']}") save_progress(idx + 1) continue if already_spider(job): print(f"[{idx + 1}/{len(all_jobs)}] 跳过已抓: {job['title']}") save_progress(idx + 1) continue try: print(f"[{idx + 1}/{len(all_jobs)}] 正在抓取: {job['title']}") # 打开详情页 page.goto(job["link"], timeout=TIMEOUT, wait_until="domcontentloaded") # 等待详情内容加载 page.wait_for_selector("div.content", timeout=TIMEOUT) # 额外等待,确保动态内容完全加载 page.wait_for_timeout(500) # 解析详情内容 job["description"] = parse_detail_page(page.content()) # 保存数据 saved_to = save(job) print(f" 已保存: {saved_to}") except Exception as e: print(f" 抓取失败: {e}") # 更新进度 save_progress(idx + 1) # 随机休眠,控制请求频率 time.sleep(random.uniform(1, 2)) print("\n全部完成!") except Exception as e: print(f"发生错误: {e}") finally: # 关闭浏览器 browser.close() ``` 添加 context.add\_init\_script(): - 在每个页面加载前自动注入JavaScript代码 - 对所有页面生效,无需重复设置 伪装的关键字段: - navigator.webdriver:最核心的检测点,改为 undefined - window.chrome:模拟Chrome浏览器的特有对象 - navigator.plugins:伪造插件列表,让浏览器看起来更真实 - navigator.languages:设置语言偏好 - 删除 webdriver 原型链:彻底清除自动化特征 反检测效果: - 绕过基于 navigator.webdriver 的简单检测 - 模拟真实浏览器的环境特征 - 降低被识别为机器人的概率 注意事项: - 这只是基础伪装,高级反爬系统还会检测鼠标轨迹、键盘输入等行为特征 - 如果需要更强的伪装,可以考虑使用 playwright-stealth 库 - 配合合理的请求间隔和User-Agent轮换效果更好 ## **八、真正的杀手锏:**`page.on("response")` **监听接口** 到这里我们的爬虫已经能抓动态网站了。但现在出现一个新问题:**用 DOM 解析职位数据,字段总是不全、不稳定**。 为什么?因为页面上显示的内容,是前端把接口返回的 JSON **二次加工**后的结果。比如: - 接口返回的薪资是 `{"min": 15000, "max": 25000, "currency": "CNY"}` - 页面渲染成 `"15-25k·13薪"` 从 DOM 读到的是后者,要还原成结构化数据还要自己解析字符串,**容易出错**。 **有没有办法直接拿到接口的 JSON?** 有!Playwright 提供了一个绝杀技:**让浏览器正常加载页面,但我们在旁边"偷听"接口返回**。 ```python captured_data = [] def on_response(response): if "search-job" in response.url: # 匹配目标接口 try: data = response.json() # 直接拿 JSON captured_data.append(data) print(f"拦截到: {response.url}") except Exception: pass page.on("response", on_response) page.goto("https://www.liepin.com/zhaopin/") # 正常打开页面 page.wait_for_load_state("networkidle") # 现在 captured_data 里就是接口返回的原始 JSON ``` ```javascript """ 生产级别的 Playwright 爬虫模板 功能:支持动态渲染页面、翻页、等待数据加载、去重、断点续跑、异常处理、节奏控制、资源屏蔽优化、反自动化伪装、API接口监听 """ import os import json import time import random from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout from bs4 import BeautifulSoup # ==================== 配置区 ==================== # 数据保存目录 DATA_DIR = "data/jobs" # 进度记录文件路径 PROGRESS_FILE = "data/progress.txt" # 列表页URL(如果有分页,可以改成带页码的格式) LIST_URL = "https://realpython.github.io/fake-jobs/" # 浏览器配置 HEADLESS = False # True为无头模式(不显示浏览器窗口) TIMEOUT = 30000 # 超时时间(毫秒) # ==================== API接口监听器 ==================== class APICapture: """ API响应拦截器 用于捕获页面加载过程中的AJAX请求返回的JSON数据 """ def __init__(self): self.captured_data = [] # 存储捕获到的API数据 self.target_keywords = [] # 目标接口的关键词列表 def set_target_keywords(self, keywords): """ 设置需要监听的接口关键词 Args: keywords (list): URL中包含的关键词列表,如 ['api/jobs', 'search'] """ self.target_keywords = keywords def on_response(self, response): """ 响应拦截回调函数 当浏览器收到任何HTTP响应时自动调用 Args: response: Playwright的Response对象 """ url = response.url # 检查URL是否包含目标关键词 if any(keyword in url for keyword in self.target_keywords): try: # 尝试解析JSON数据 data = response.json() # 存储捕获的数据,包含URL和响应内容 self.captured_data.append({ "url": url, "status": response.status, "data": data, "timestamp": time.time() }) print(f"[API拦截] {url} (状态码: {response.status})") except Exception as e: # 如果响应不是JSON格式,忽略 pass def get_captured_data(self): """ 获取所有捕获到的API数据 Returns: list: 捕获到的API响应数据列表 """ return self.captured_data.copy() def clear(self): """清空已捕获的数据""" self.captured_data.clear() # ==================== 解析函数 ==================== def parse_list_page(html): """ 解析列表页,提取职位基本信息 Args: html (str): 列表页HTML内容 Returns: list: 职位信息字典列表 """ soup = BeautifulSoup(html, "lxml") jobs = [] for card in soup.select("div.card-content"): # 查找"Apply"按钮获取正确的详情页链接 apply_link = card.find("a", string="Apply") link = apply_link["href"] if apply_link else None jobs.append({ "title": card.select_one("h2.title").get_text(strip=True), "company": card.select_one("h3.company").get_text(strip=True), "location": card.select_one("p.location").get_text(strip=True), "link": link, }) return jobs def parse_detail_page(html): """ 解析详情页,提取职位描述 Args: html (str): 详情页HTML内容 Returns: str: 职位描述文本 """ soup = BeautifulSoup(html, "lxml") node = soup.select_one("div.content") return node.get_text(strip=True) if node else "" # ==================== 数据存储函数 ==================== def save(job): """ 将职位数据保存为JSON文件 Args: job (dict): 职位信息字典 Returns: str: 保存的文件路径 """ job_id = job["link"].rstrip("/").split("/")[-1].replace(".html", "") path = f"{DATA_DIR}/{job_id}.json" with open(path, "w", encoding="utf-8") as f: json.dump(job, f, ensure_ascii=False, indent=2) return path def already_spider(job): """ 检查职位是否已经爬取过 Args: job (dict): 职位信息字典 Returns: bool: True表示已爬取 """ if not job["link"]: return False job_id = job["link"].rstrip("/").split("/")[-1].replace(".html", "") return os.path.exists(f"{DATA_DIR}/{job_id}.json") # ==================== 进度管理函数 ==================== def load_progress(): """ 加载爬取进度 Returns: int: 已处理的职位索引 """ if os.path.exists(PROGRESS_FILE): try: return int(open(PROGRESS_FILE).read()) except: return 0 return 0 def save_progress(idx): """ 保存爬取进度 Args: idx (int): 当前处理到的索引 """ with open(PROGRESS_FILE, "w") as f: f.write(str(idx)) # ==================== 主程序入口 ==================== if __name__ == "__main__": # 创建数据保存目录 os.makedirs(DATA_DIR, exist_ok=True) all_jobs = [] # 存储所有职位 api_capture = APICapture() # 创建API拦截器 # 设置要监听的API接口关键词(根据实际网站调整) # 例如:['api/jobs', 'search', 'recruitment'] api_capture.set_target_keywords(["fake-jobs"]) # 示例:监听包含fake-jobs的接口 with sync_playwright() as p: # 启动浏览器 browser = p.chromium.launch(headless=HEADLESS) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", viewport={"width": 1280, "height": 800}, ) # 反自动化伪装:注入JavaScript脚本,隐藏Playwright特征 context.add_init_script(""" // 伪装 navigator.webdriver 字段 // 真人浏览器返回 undefined,Playwright默认返回 true Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); // 伪装 Chrome 对象(某些网站会检查 window.chrome) window.chrome = { runtime: {}, loadTimes: function() {}, csi: function() {}, app: {} }; // 伪装 plugins(插件列表) Object.defineProperty(navigator, 'plugins', { get: () => [ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' }, { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' }, { name: 'Native Client', filename: 'internal-nacl-plugin' } ] }); // 伪装 languages(语言设置) Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'] }); // 移除 automation 相关的特征 delete navigator.__proto__.webdriver; """) # 性能优化:屏蔽图片、字体、视频等无关资源 def block_resources(route): """ 拦截并屏蔽不必要的资源请求,提升爬取速度 屏蔽的资源类型: - image: 图片(jpg, png, gif, webp等) - font: 字体文件(woff, ttf等) - media: 音视频文件 - stylesheet: CSS样式表(可选,如果不需要渲染样式) """ if route.request.resource_type in ("image", "font", "media"): route.abort() # 取消这个请求,节省带宽和时间 else: route.continue_() # 其他资源正常放行(HTML、JS、XHR等) # 应用路由拦截规则,**/* 匹配所有请求 context.route("**/*", block_resources) page = context.new_page() # 注册API响应监听器 page.on("response", api_capture.on_response) try: # ---- 第1步:抓取列表页(支持翻页)---- current_page = 1 max_pages = 3 # 最大翻页数,根据需要调整 while current_page <= max_pages: print(f"\n===== 正在抓取第 {current_page} 页 =====") # 清空之前捕获的API数据 api_capture.clear() # 如果是第一页,直接访问;否则点击下一页按钮 if current_page == 1: page.goto(LIST_URL, timeout=TIMEOUT, wait_until="domcontentloaded") else: # 尝试点击下一页按钮(根据实际网站结构调整选择器) next_btn = page.query_selector("a.next-page, button.next, .pagination a:last-child") if not next_btn: print("没有更多页面了") break next_btn.click() # 等待新页面加载完成 page.wait_for_load_state("networkidle", timeout=TIMEOUT) # 等待职位卡片加载完成 try: page.wait_for_selector("div.card-content", timeout=TIMEOUT) except PlaywrightTimeout: print(f"第 {current_page} 页加载超时") break # 额外等待,确保动态内容和API请求完成 page.wait_for_timeout(1500) # 检查是否捕获到API数据 captured = api_capture.get_captured_data() if captured: print(f"[API数据] 捕获到 {len(captured)} 个API响应") # 这里可以处理API返回的JSON数据 # 例如:直接从JSON中提取职位信息,比DOM解析更准确 for item in captured: print(f" - URL: {item['url']}") # print(f" - 数据: {json.dumps(item['data'], ensure_ascii=False)[:200]}") else: print("[API数据] 未捕获到匹配的API响应") # 解析当前页的职位列表(备用方案:如果API拦截失败,仍可用DOM解析) jobs = parse_list_page(page.content()) print(f"第 {current_page} 页抓到 {len(jobs)} 条") if not jobs: print("没有更多数据了") break all_jobs.extend(jobs) current_page += 1 # 翻页间隔 time.sleep(random.uniform(1, 2)) print(f"\n列表页总共抓到 {len(all_jobs)} 条职位") # ---- 第2步:加载进度,实现断点续跑 ---- start = load_progress() print(f"从第 {start} 条开始抓取详情") # ---- 第3步:逐个抓取详情页 ---- for idx, job in enumerate(all_jobs): # 跳过已处理的职位(断点续跑) if idx < start: continue # 跳过无效链接或已爬取的职位 if not job["link"]: print(f"[{idx + 1}/{len(all_jobs)}] 跳过: 无链接 - {job['title']}") save_progress(idx + 1) continue if already_spider(job): print(f"[{idx + 1}/{len(all_jobs)}] 跳过已抓: {job['title']}") save_progress(idx + 1) continue try: print(f"[{idx + 1}/{len(all_jobs)}] 正在抓取: {job['title']}") # 清空API缓存,准备捕获详情页的接口 api_capture.clear() # 打开详情页 page.goto(job["link"], timeout=TIMEOUT, wait_until="domcontentloaded") # 等待详情内容加载 page.wait_for_selector("div.content", timeout=TIMEOUT) # 额外等待,确保动态内容和API请求完成 page.wait_for_timeout(1000) # 检查是否捕获到详情页的API数据 detail_api_data = api_capture.get_captured_data() if detail_api_data: print(f" [API数据] 捕获到 {len(detail_api_data)} 个API响应") # 可以直接从API数据中提取结构化信息 # 这比DOM解析更可靠,特别是对于薪资、福利等复杂字段 # 解析详情内容(备用方案) job["description"] = parse_detail_page(page.content()) # 保存数据 saved_to = save(job) print(f" 已保存: {saved_to}") except Exception as e: print(f" 抓取失败: {e}") # 更新进度 save_progress(idx + 1) # 随机休眠,控制请求频率 time.sleep(random.uniform(1, 2)) print("\n全部完成!") except Exception as e: print(f"发生错误: {e}") finally: # 关闭浏览器 browser.close() ``` **这一招的精妙之处:** | 方案 | 优点 | 缺点 | | --- | --- | --- | | 自己构造 API 请求 | 速度最快 | 签名难破解、参数易变 | | 解析 DOM | 简单直观 | 字段不全、易受样式变化影响 | | ****`on_response` **监听** | **拿原始 JSON、不破解签名、稳定** | **要打开浏览器,慢一点** | 这就是猎聘项目里最核心的设计——**"页面操作触发 + 接口监听获取"混合方案**: - 让 Playwright 像真人一样点击筛选项 → 触发接口请求 - 我们在旁边监听 → 接住原始 JSON - **不需要研究接口签名,也不需要解析复杂 DOM** #### **猎聘项目里的真实用法** ```python def onResponse(self, response): if "pc-search-job" in response.url: ret = response.json() self.jsonData = ret['data']['data']['jobCardList'] # 在主流程里: page.on("response", self.onResponse) page.goto("https://www.liepin.com/zhaopin/") # 操作筛选项,触发接口 page.click("...") # 等接口数据回来 while not self.jsonData: time.sleep(0.5) # 现在可以从 self.jsonData 读结构化职位列表了 ``` **这就是为什么项目代码里到处都是** `self.jsonData`**、**`onResponese`**** —— 它不是个奇怪的设计,而是一个被验证过的、性价比最高的反爬绕过方案。 ## **九、加代理:换 IP 出口** 到目前为止,所有请求都从你家网络出去——**同一个 IP**。一旦抓得勤了,网站会发现"这个 IP 一分钟点了 50 次,肯定不是真人",直接封你。 代理就是"中转站",让你的请求**从别人的 IP 出去**。 ```java browser = p.chromium.launch( headless=False, proxy={ "server": "http://proxy.example.com:8080", "username": "user", "password": "pass", }, ) ``` 加这一个 `proxy` 参数,**所有从这个浏览器出去的流量都走代理**。被封了?换一个代理重启浏览器就行。 #### **代理池的概念** 单个代理也会被封。所以工程化爬虫一定有"代理池": ```text 代理池服务 ←→ 爬虫脚本 │ ├─ 我有 100 个代理 ├─ 你领一个去用 ├─ 你用完了告诉我"被封了 / 还能用" └─ 我帮你统一管理、调度、补货 ``` 这就是 `spider.py` 里 `getProxy()` / `setProxyRecord()` 的全部逻辑。爬虫不直接管代理,只管"领用 + 上报",代理池服务统一调度。 ## **十、风控识别:被发现了怎么办?** 即使做了所有伪装,还是会被发现。那么**怎么知道自己被发现了**? 最简单的方法:**检查页面标题**。 ```python page.goto(url) title = page.title() if title in ("猎聘", "405", "Access Denied", ""): print("可能被风控了") change_proxy() return ``` 为什么能用标题判断?因为正常职位页的标题是 "Python 工程师 - 猎聘",而被拦截后会变成纯 "猎聘" 或显示 "405"。这是猎聘项目里基于线上观察总结出的经验规则。 **更复杂的判断:** - URL 是否被重定向到验证页 - 页面是否出现滑块验证码 `.verify-slider` - HTTP 状态码是不是 405 / 403 - 页面文本里是否出现 "请输入验证码" ```python if page.query_selector(".slider-verify"): print("出现滑块验证") # 1. 切代理 # 2. 或者模拟拖动滑块 # 3. 或者休眠到第二天 ``` 鱼泡爬虫甚至做了**模拟滑块拖动**——但这已经是高阶玩法了,通常我们直接换代理。 ## 十一、把所有积木拼到一起:Playwright 工程化版本 ```javascript """ 生产级别的 Playwright 爬虫模板 功能:支持动态渲染页面、翻页、等待数据加载、去重、断点续跑、异常处理、节奏控制、资源屏蔽优化、反自动化伪装、API接口监听、代理切换、风控识别 """ import os import json import time import random from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout from bs4 import BeautifulSoup # ==================== 配置区 ==================== # 数据保存目录 DATA_DIR = "data/jobs" # 进度记录文件路径 PROGRESS_FILE = "data/progress.txt" # 列表页URL(如果有分页,可以改成带页码的格式) LIST_URL = "https://realpython.github.io/fake-jobs/" # 浏览器配置 HEADLESS = False # True为无头模式(不显示浏览器窗口) TIMEOUT = 30000 # 超时时间(毫秒) # 代理池配置(从代理服务获取或手动维护) PROXY_POOL = [ "1.92.159.127:8083", "121.36.37.160:8083", "119.3.219.36:8083", "119.3.254.109:8083", "1.92.104.120:8083", "113.44.168.74:8083", "124.70.74.27:8083", "121.36.107.179:8083", "119.3.218.18:8083", "124.70.69.47:8083", "121.36.83.164:8083", "124.70.78.220:8083", "121.36.102.6:8083", "120.46.135.31:8083", "113.44.236.170:8083", "113.44.177.53:8083", ] # 风控检测关键词 RISK_TITLES = ["安全访问验证", "Access Denied", "405", "403", "验证码"] RISK_SELECTORS = [".slider-verify", ".verify-slider", "#captcha", "[class*='captcha']"] # ==================== 代理管理器 ==================== class ProxyManager: """ 代理池管理器 负责代理的分配、轮换、状态记录 """ def __init__(self, proxy_pool): """ 初始化代理管理器 Args: proxy_pool (list): 代理地址列表,格式如 ["ip:port", ...] """ self.proxy_pool = proxy_pool.copy() self.current_index = 0 self.failed_proxies = set() # 记录失败的代理 self.success_proxies = set() # 记录成功的代理 def get_next_proxy(self): """ 获取下一个可用代理(轮询方式) Returns: dict or None: 代理配置字典,如果没有可用代理返回None 格式:{"server": "http://ip:port"} """ if not self.proxy_pool: return None # 尝试找到未失败的代理 max_attempts = len(self.proxy_pool) attempts = 0 while attempts < max_attempts: proxy_addr = self.proxy_pool[self.current_index] self.current_index = (self.current_index + 1) % len(self.proxy_pool) # 跳过已标记为失败的代理 if proxy_addr not in self.failed_proxies: print(f"[代理] 使用: {proxy_addr}") return {"server": f"http://{proxy_addr}"} attempts += 1 # 如果所有代理都失败了,重置失败记录,重新尝试 print("[代理] 所有代理都已失败,重置后重试") self.failed_proxies.clear() return self.get_next_proxy() def report_success(self, proxy_addr): """ 报告代理使用成功 Args: proxy_addr (str): 代理地址 """ if proxy_addr: self.success_proxies.add(proxy_addr) self.failed_proxies.discard(proxy_addr) print(f"[代理] 报告成功: {proxy_addr}") def report_failure(self, proxy_addr): """ 报告代理使用失败(被封或不可用) Args: proxy_addr (str): 代理地址 """ if proxy_addr: self.failed_proxies.add(proxy_addr) print(f"[代理] 报告失败: {proxy_addr}") # ==================== API接口监听器 ==================== class APICapture: """ API响应拦截器 用于捕获页面加载过程中的AJAX请求返回的JSON数据 """ def __init__(self): self.captured_data = [] # 存储捕获到的API数据 self.target_keywords = [] # 目标接口的关键词列表 def set_target_keywords(self, keywords): """ 设置需要监听的接口关键词 Args: keywords (list): URL中包含的关键词列表,如 ['api/jobs', 'search'] """ self.target_keywords = keywords def on_response(self, response): """ 响应拦截回调函数 当浏览器收到任何HTTP响应时自动调用 Args: response: Playwright的Response对象 """ url = response.url # 检查URL是否包含目标关键词 if any(keyword in url for keyword in self.target_keywords): try: # 尝试解析JSON数据 data = response.json() # 存储捕获的数据,包含URL和响应内容 self.captured_data.append({ "url": url, "status": response.status, "data": data, "timestamp": time.time() }) print(f"[API拦截] {url} (状态码: {response.status})") except Exception as e: # 如果响应不是JSON格式,忽略 pass def get_captured_data(self): """ 获取所有捕获到的API数据 Returns: list: 捕获到的API响应数据列表 """ return self.captured_data.copy() def clear(self): """清空已捕获的数据""" self.captured_data.clear() # ==================== 风控检测器 ==================== def check_risk(page): """ 检测当前页面是否被风控 Args: page: Playwright的Page对象 Returns: bool: True表示检测到风控,False表示正常 """ # 方法1:检查页面标题 title = page.title() if any(keyword in title for keyword in RISK_TITLES): print(f"[风控] 检测到风险标题: {title}") return True # 方法2:检查页面是否出现验证码元素 for selector in RISK_SELECTORS: if page.query_selector(selector): print(f"[风控] 检测到验证码元素: {selector}") return True # 方法3:检查页面文本是否包含验证码提示 page_text = page.text_content("body") if page_text and any(keyword in page_text for keyword in ["请输入验证码", "滑块验证", "安全验证"]): print("[风控] 检测到验证码文本") return True # 方法4:检查HTTP状态码(需要在请求时捕获) # 这里简化处理,实际可以通过监听response来实现 return False def handle_risk(page, proxy_manager, current_proxy): """ 处理风控情况 Args: page: Playwright的Page对象 proxy_manager: 代理管理器实例 current_proxy: 当前使用的代理地址 """ print("[风控] 触发风控处理流程") # 1. 报告当前代理失败 if current_proxy: proxy_manager.report_failure(current_proxy) # 2. 获取新代理 new_proxy = proxy_manager.get_next_proxy() if new_proxy: print(f"[风控] 切换到新代理: {new_proxy['server']}") else: print("[风控] 没有可用代理,建议稍后重试") # 3. 可选:等待一段时间再重试 wait_time = random.uniform(5, 10) print(f"[风控] 等待 {wait_time:.1f} 秒后重试") time.sleep(wait_time) # ==================== 解析函数 ==================== def parse_list_page(html): """ 解析列表页,提取职位基本信息 Args: html (str): 列表页HTML内容 Returns: list: 职位信息字典列表 """ soup = BeautifulSoup(html, "lxml") jobs = [] for card in soup.select("div.card-content"): # 查找"Apply"按钮获取正确的详情页链接 apply_link = card.find("a", string="Apply") link = apply_link["href"] if apply_link else None jobs.append({ "title": card.select_one("h2.title").get_text(strip=True), "company": card.select_one("h3.company").get_text(strip=True), "location": card.select_one("p.location").get_text(strip=True), "link": link, }) return jobs def parse_detail_page(html): """ 解析详情页,提取职位描述 Args: html (str): 详情页HTML内容 Returns: str: 职位描述文本 """ soup = BeautifulSoup(html, "lxml") node = soup.select_one("div.content") return node.get_text(strip=True) if node else "" # ==================== 数据存储函数 ==================== def save(job): """ 将职位数据保存为JSON文件 Args: job (dict): 职位信息字典 Returns: str: 保存的文件路径 """ job_id = job["link"].rstrip("/").split("/")[-1].replace(".html", "") path = f"{DATA_DIR}/{job_id}.json" with open(path, "w", encoding="utf-8") as f: json.dump(job, f, ensure_ascii=False, indent=2) return path def already_spider(job): """ 检查职位是否已经爬取过 Args: job (dict): 职位信息字典 Returns: bool: True表示已爬取 """ if not job["link"]: return False job_id = job["link"].rstrip("/").split("/")[-1].replace(".html", "") return os.path.exists(f"{DATA_DIR}/{job_id}.json") # ==================== 进度管理函数 ==================== def load_progress(): """ 加载爬取进度 Returns: int: 已处理的职位索引 """ if os.path.exists(PROGRESS_FILE): try: return int(open(PROGRESS_FILE).read()) except: return 0 return 0 def save_progress(idx): """ 保存爬取进度 Args: idx (int): 当前处理到的索引 """ with open(PROGRESS_FILE, "w") as f: f.write(str(idx)) # ==================== 主程序入口 ==================== if __name__ == "__main__": # 创建数据保存目录 os.makedirs(DATA_DIR, exist_ok=True) all_jobs = [] # 存储所有职位 api_capture = APICapture() # 创建API拦截器 proxy_manager = ProxyManager(PROXY_POOL) # 创建代理管理器 # 设置要监听的API接口关键词(根据实际网站调整) # 例如:['api/jobs', 'search', 'recruitment'] api_capture.set_target_keywords(["fake-jobs"]) # 示例:监听包含fake-jobs的接口 # 获取初始代理 current_proxy_config = proxy_manager.get_next_proxy() current_proxy_addr = current_proxy_config["server"].replace("http://", "") if current_proxy_config else None with sync_playwright() as p: # 启动浏览器(带代理) browser_kwargs = {"headless": HEADLESS} if current_proxy_config: browser_kwargs["proxy"] = current_proxy_config browser = p.chromium.launch(**browser_kwargs) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", viewport={"width": 1280, "height": 800}, ) # 反自动化伪装:注入JavaScript脚本,隐藏Playwright特征 context.add_init_script(""" // 伪装 navigator.webdriver 字段 // 真人浏览器返回 undefined,Playwright默认返回 true Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); // 伪装 Chrome 对象(某些网站会检查 window.chrome) window.chrome = { runtime: {}, loadTimes: function() {}, csi: function() {}, app: {} }; // 伪装 plugins(插件列表) Object.defineProperty(navigator, 'plugins', { get: () => [ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' }, { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' }, { name: 'Native Client', filename: 'internal-nacl-plugin' } ] }); // 伪装 languages(语言设置) Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'] }); // 移除 automation 相关的特征 delete navigator.__proto__.webdriver; """) # 性能优化:屏蔽图片、字体、视频等无关资源 def block_resources(route): """ 拦截并屏蔽不必要的资源请求,提升爬取速度 屏蔽的资源类型: - image: 图片(jpg, png, gif, webp等) - font: 字体文件(woff, ttf等) - media: 音视频文件 - stylesheet: CSS样式表(可选,如果不需要渲染样式) """ if route.request.resource_type in ("image", "font", "media"): route.abort() # 取消这个请求,节省带宽和时间 else: route.continue_() # 其他资源正常放行(HTML、JS、XHR等) # 应用路由拦截规则,**/* 匹配所有请求 context.route("**/*", block_resources) page = context.new_page() # 注册API响应监听器 page.on("response", api_capture.on_response) try: # ---- 第1步:抓取列表页(支持翻页)---- current_page = 1 max_pages = 3 # 最大翻页数,根据需要调整 while current_page <= max_pages: print(f"\n===== 正在抓取第 {current_page} 页 =====") # 清空之前捕获的API数据 api_capture.clear() # 如果是第一页,直接访问;否则点击下一页按钮 if current_page == 1: page.goto(LIST_URL, timeout=TIMEOUT, wait_until="domcontentloaded") else: # 尝试点击下一页按钮(根据实际网站结构调整选择器) next_btn = page.query_selector("a.next-page, button.next, .pagination a:last-child") if not next_btn: print("没有更多页面了") break next_btn.click() # 等待新页面加载完成 page.wait_for_load_state("networkidle", timeout=TIMEOUT) # 风控检测 if check_risk(page): handle_risk(page, proxy_manager, current_proxy_addr) # 重启浏览器使用新代理 browser.close() current_proxy_config = proxy_manager.get_next_proxy() current_proxy_addr = current_proxy_config["server"].replace("http://", "") if current_proxy_config else None browser_kwargs = {"headless": HEADLESS} if current_proxy_config: browser_kwargs["proxy"] = current_proxy_config browser = p.chromium.launch(**browser_kwargs) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", viewport={"width": 1280, "height": 800}, ) context.add_init_script(""" Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); window.chrome = {runtime: {}, loadTimes: function() {}, csi: function() {}, app: {}}; """) context.route("**/*", block_resources) page = context.new_page() page.on("response", api_capture.on_response) continue # 等待职位卡片加载完成 try: page.wait_for_selector("div.card-content", timeout=TIMEOUT) except PlaywrightTimeout: print(f"第 {current_page} 页加载超时") break # 额外等待,确保动态内容和API请求完成 page.wait_for_timeout(1500) # 检查是否捕获到API数据 captured = api_capture.get_captured_data() if captured: print(f"[API数据] 捕获到 {len(captured)} 个API响应") # 这里可以处理API返回的JSON数据 # 例如:直接从JSON中提取职位信息,比DOM解析更准确 for item in captured: print(f" - URL: {item['url']}") # print(f" - 数据: {json.dumps(item['data'], ensure_ascii=False)[:200]}") else: print("[API数据] 未捕获到匹配的API响应") # 解析当前页的职位列表(备用方案:如果API拦截失败,仍可用DOM解析) jobs = parse_list_page(page.content()) print(f"第 {current_page} 页抓到 {len(jobs)} 条") if not jobs: print("没有更多数据了") break all_jobs.extend(jobs) current_page += 1 # 翻页间隔 time.sleep(random.uniform(1, 2)) # 报告代理成功 if current_proxy_addr: proxy_manager.report_success(current_proxy_addr) print(f"\n列表页总共抓到 {len(all_jobs)} 条职位") # ---- 第2步:加载进度,实现断点续跑 ---- start = load_progress() print(f"从第 {start} 条开始抓取详情") # ---- 第3步:逐个抓取详情页 ---- for idx, job in enumerate(all_jobs): # 跳过已处理的职位(断点续跑) if idx < start: continue # 跳过无效链接或已爬取的职位 if not job["link"]: print(f"[{idx + 1}/{len(all_jobs)}] 跳过: 无链接 - {job['title']}") save_progress(idx + 1) continue if already_spider(job): print(f"[{idx + 1}/{len(all_jobs)}] 跳过已抓: {job['title']}") save_progress(idx + 1) continue try: print(f"[{idx + 1}/{len(all_jobs)}] 正在抓取: {job['title']}") # 清空API缓存,准备捕获详情页的接口 api_capture.clear() # 打开详情页 page.goto(job["link"], timeout=TIMEOUT, wait_until="domcontentloaded") # 风控检测 if check_risk(page): handle_risk(page, proxy_manager, current_proxy_addr) # 详情页遇到风控,跳过该职位 save_progress(idx + 1) continue # 等待详情内容加载 page.wait_for_selector("div.content", timeout=TIMEOUT) # 额外等待,确保动态内容和API请求完成 page.wait_for_timeout(1000) # 检查是否捕获到详情页的API数据 detail_api_data = api_capture.get_captured_data() if detail_api_data: print(f" [API数据] 捕获到 {len(detail_api_data)} 个API响应") # 可以直接从API数据中提取结构化信息 # 这比DOM解析更可靠,特别是对于薪资、福利等复杂字段 # 解析详情内容(备用方案) job["description"] = parse_detail_page(page.content()) # 保存数据 saved_to = save(job) print(f" 已保存: {saved_to}") except Exception as e: print(f" 抓取失败: {e}") # 更新进度 save_progress(idx + 1) # 随机休眠,控制请求频率 time.sleep(random.uniform(1, 2)) print("\n全部完成!") except Exception as e: print(f"发生错误: {e}") finally: # 关闭浏览器 browser.close() ``` **这一版用到的全部积木:** | 积木 | 项目里对应 | | --- | --- | | Playwright 启动浏览器 | `spider.py::toInstance()` | | 反自动化注入 | `spider.py::addInitScript()` | | 屏蔽图片字体 | `spider.py::__handleRequest()` | | 接口监听 | `spider_liepin.py::onResponese()` | | 风控识别 | 检查 title == "猎聘" | | 代理参数 | `spider.py::getProxy()` | | 文件级去重 | `isSpiderToday()` 雏形 | 这些代码已经具备了**生产级爬虫的完整骨架**。猎聘那 1000 行,本质上就是在这些代码的基础上,把每一项做得更精细。 --- ## **十二、回头看:四种采集通道现在全打通了** 第 2 讲我们说有四种采集通道,现在应该全部理解了: | 通道 | 工具 | 适用场景 | 项目位置 | | --- | --- | --- | --- | | **静态 HTML** | `requests` + BS4 | 服务端直出 HTML | 部分老旧网站、学校公告 | | **API 直连** | `requests.get(api)` | 接口简单、无签名 | `auto_api/` 百度京东金蝶 | | **动态浏览器** | Playwright + DOM 解析 | JS 渲染、需登录 | 鱼泡、猎聘详情页 | | **接口监听** | `page.on("response")` | 接口有签名但能在浏览器触发 | 猎聘列表、`auto_on_response/` | **没有"哪种最好",只有"哪种最合适"**。猎聘项目就是把**通道 3 + 通道 4 混合**:用 Playwright 操作页面触发请求,用 `on_response` 接住 JSON。 这种"半接口、半页面"的混合方案,是目前对抗中高强度反爬最稳的方案。 --- ## **十三、这一讲的关键认知** 讲到这里,应该已经形成了一个非常重要的判断力: **面对一个新网站,知道怎么"侦察"它,然后选对工具:** ```text 打开开发者工具 F12 │ ├─ HTML 里直接有数据吗? → 用 requests │ ├─ 数据是接口加载的,接口好请求吗? → 用 requests 直接打接口 │ ├─ 接口有复杂签名,但能在浏览器触发? → Playwright + on_response │ └─ 接口完全无法分析,只能读 DOM? → Playwright + DOM 解析 ``` **这是工程师视角的"工具选型决策"**——不是"哪个方案酷",而是"哪个方案能用最少的代价拿到数据"。 --- **组内导航**:⬅️ [[04-用积木拼一个最小职位爬虫|用积木拼一个最小职位爬虫]] | 🏠 [[00-爬虫课程六讲|00-爬虫课程六讲]] | ➡️ [[06-练手|练手]]